Skip to content

[APPS-2947] Add: return connection IDs from debugBundle and attach the bundle to framed backend calls - #566

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 2 commits into
masterfrom
tiffany.trinh/apps-2947-debug-bundle-connection-ids
Oct 26, 2026
Merged

gh-worker-dd-mergequeue-cf854d[bot] merged 2 commits into
masterfrom
tiffany.trinh/apps-2947-debug-bundle-connection-ids

Conversation

@tyffical

@tyffical tyffical commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

  • APPS-2947: the Live Preview pane in the Datadog app runs a backend function's bundle through App Builder's preview-async as the viewer, and its connection checks and consent prompt need that function's allowed connection IDs.
  • The dev server already computes those IDs for /__dd/debugBundle, then drops them; the endpoint returns only plain-text code.
  • For the MVP the framed app attaches its own bundle to each backend call it posts to the parent (ADR); deployed apps ship none of the dev client and send the call as before.
  • Related: [APPS-2947 Fix: fail the build on connection IDs read from objects that calls or aliases write to]([APPS-2947] Fix: fail the build on connection IDs read from objects that calls or aliases write to #619) builds on this PR.

Architecture

flowchart LR
    subgraph frame["Preview frame (dev-served page)"]
        T["postMessageTransport"]
        DC["apps-dev-client hook (dev pages only)"]
    end
    DS["Dev server: POST /__dd/debugBundle"]
    P["Parent: Live Preview pane"]
    T -- "hook installed? await it, max 120s" --> DC
    DC -- "Accept: application/json (else plain text, as before)" --> DS
    DS -- "code + allowedConnectionIds" --> DC
    DC -- "targetOrigin + bundle" --> T
    T -- "run-query + bundle, exact https Datadog origin only" --> P
    P -- "run-query:response, from window.parent only" --> T
Loading
// app-builder:run-query, iframe → parent
{ type: 'app-builder:run-query', requestId, queryName, args,
  bundle?: { code: string; allowedConnectionIds: string[] } }
  • The parent origin is location.ancestorOrigins[0], or in Firefox the origin of window.parent's answer to a ping. Any hook, ping or fetch failure posts the call without a bundle, so deployed apps, which have no hook, post to '*' as before.

Changes

11 changes across the protocol, dev server, Vite plugin, dev client, transport and tests
What changed File
Add DebugBundleResponse, DEBUG_BUNDLE_PATH, the message-type constants and the dev-client hook types packages/plugins/apps/src/backend/protocol.ts
Return { code, allowedConnectionIds } as JSON when Accept lists application/json; plain text otherwise packages/plugins/apps/src/vite/dev-server.ts
Serve virtual:dd-apps-dev-client only while a dev server runs, and link it from dev-served HTML packages/plugins/apps/src/vite/index.ts
Add the apps-dev-client sub-build and its entry packages/plugins/apps/package.json, packages/plugins/apps/src/built/apps-dev-client.ts
Find a Datadog parent, fetch the bundle, and warn when the call goes out without one packages/plugins/apps/src/backend/client/dev-client/dev-client.ts
Call the hook if present, check its result, and accept replies only from the recipient packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts, types.ts
Share the bundle guards and request ids between the transport and the dev client packages/plugins/apps/src/backend/client/guards.ts, packages/plugins/apps/src/backend/client/request-id.ts
Cover the dev client and the transport dev-client.test.ts, post-message-transport.test.ts
Cover the dev-only injection, JSON negotiation and a real dev server with and without base packages/plugins/apps/src/vite/index.test.ts, dev-server.test.ts, dev-server.integration.test.ts, dev-client-build.test.ts
Add a shared fake window for frame tests packages/tests/src/_jest/helpers/fakeWindow.ts
Point the postMessage note at the new transport tests packages/plugins/apps/src/backend/client/execute-backend-function.test.ts

QA Instructions

  • Dev server, browser and build checks pass, verified at 477b4b9e, and in staging on 3.3.3-dev.1, a dev publish of that head. Setup: Local QA without npm link in the testing and QA guide, with the harness below.
Check Result Evidence
/__dd/debugBundle Plain text by default; {code, allowedConnectionIds} JSON with Accept: application/json; master's 415/403 request checks unchanged Local dev server
Dev client Served on dev pages, under a base path too; absent from a production build Local dev server and build
Datadog parent, Chromium and Firefox Bundle with allowedConnectionIds ["conn-1"], from http and https frames Local Playwright
Look-alike or same-origin parent No bundle; the call still runs Local Playwright
Firefox ping unanswered or late First call waits about 2s without a bundle; later calls carry it; one warning Local Playwright
Bundle size apps-runtime.mjs 3267 bytes; apps-dev-client.mjs 3185 bytes, dev pages only Built plugin
Live Preview in staging On 3.3.3-dev.1, the demo app's framed call ran: its github.pulls.list action was accepted for high-code 5s after Vite restarted on the new version APM spans in a Datadog staging org
Dev server checks (curl)
  • Live dev server: the built plugin from this branch, qa-server.cjs and qa-server-base.cjs below, with the actionCatalogCall.backend.ts fixture under the app root and no auth.
QN=$(curl -s http://localhost:5199/backend/actionCatalogCall.backend.ts | grep -o '[0-9a-f]\{64\}\.postMessage' | head -1)

curl -s -o plain.txt -w "HTTP %{http_code} %{content_type}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -d "{\"functionName\":\"$QN\"}"
# Expected: HTTP 200 text/plain ✅ VERIFIED

curl -s -o json.txt -w "HTTP %{http_code} %{content_type}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -d "{\"functionName\":\"$QN\"}"
# Expected: HTTP 200 application/json; keys [code, allowedConnectionIds]; allowedConnectionIds ["conn-1"]; code === plain.txt ✅ VERIFIED

curl -s -w "\nHTTP %{http_code} %{content_type}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"functionName":"nope.nope"}'
# Expected: {"success":false,"error":"Backend function \"nope.nope\" not found"} HTTP 404 application/json ✅ VERIFIED

curl -s http://localhost:5199/index.html | grep -o '<script type="module" src="/@id/virtual:dd-apps-dev-client"></script>'
curl -s -o /dev/null -w "dev client module: HTTP %{http_code} %{content_type}\n" http://localhost:5199/@id/virtual:dd-apps-dev-client
# Expected: the dev client tag is on dev-served HTML, and the module returns HTTP 200 text/javascript ✅ VERIFIED

curl -s http://localhost:5197/myapp/index.html | grep -o '<script type="module" src="[^"]*dd-apps-dev-client"></script>'
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:5197/myapp/@id/virtual:dd-apps-dev-client
# Expected: src="/myapp/@id/virtual:dd-apps-dev-client" and HTTP 200 under a base path ✅ VERIFIED

curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:5199/__dd/debugBundle -d "{\"functionName\":\"$QN\"}"
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -H 'Origin: http://evil.example' -d "{\"functionName\":\"$QN\"}"
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -H 'Origin: http://localhost:5199' -d "{\"functionName\":\"$QN\"}"
# Expected: 415, 403, 200: master's request checks still apply to the debugBundle route, and the dev server's own origin passes ✅ VERIFIED
Browser checks (Playwright, Chromium and Firefox)
  • Headless Chromium and Firefox through Playwright 1.49.1, with QA_BROWSER=chromium or QA_BROWSER=firefox. Playwright fulfills the parent page locally, so no request reaches the named host; the page answers app-builder:ping as App Builder's bridge does.
node qa-browser-parent.cjs https://app.datadoghq.com /index.html
# Expected: devClient true; bundleFetches ["http://localhost:5199/__dd/debugBundle"]; hasBundle true; allowedConnectionIds ["conn-1"]; frameResult "reply-from-parent", in both browsers ✅ VERIFIED

node qa-browser-parent.cjs https://app.datadoghq.com /nested/index.html
# Expected: same as above at a nested path, in both browsers ✅ VERIFIED

node qa-browser-parent.cjs https://app.datadoghq.com.evil.example /index.html
# Expected: hasBundle false; frameResult "reply-from-parent"; Chromium fetches nothing, Firefox builds once while pinging and drops it ✅ VERIFIED

node qa-browser.cjs   # parent page served by the dev server itself (http://localhost:5199)
# Expected: hasBundle false; frameResult "reply-from-parent", in both browsers ✅ VERIFIED

node qa-browser-https.cjs https://app.datadoghq.com /index.html   # frame served from https://preview.qa.test, as a real preview is
# Expected: bundleFetches ["https://preview.qa.test/__dd/debugBundle"]; hasBundle true, in both browsers ✅ VERIFIED

node qa-browser-https.cjs https://app.datadoghq.com.evil.example /index.html
# Expected: hasBundle false, in both browsers ✅ VERIFIED

node qa-browser-ping.cjs never   # parent answers run-query but ignores pings; 4 calls
# Expected: Chromium: 0 pings, bundles on every call. Firefox: the first call ≈ 2000 ms, the rest well under it; 1 ping; hasBundle [false, true, true, true]; one warning ✅ VERIFIED

node qa-browser-ping.cjs late    # parent answers each ping 3s late
# Expected: same as "never": the first reply teaches the origin before the late pong ✅ VERIFIED

node qa-build.cjs && wc -c qa-dist/assets/*.js && grep -c "app-builder:ping\|__dd/debugBundle\|datad0g\|dd-apps-dev-client" qa-dist/assets/*.js qa-dist/index.html
# Expected: the production build succeeds, its JS asset is 4179 bytes, and nothing of the dev client is in it (0 matches) ✅ VERIFIED
Live Preview in staging (steps)
  1. Publish a dev version from this head (this run: 3.3.3-dev.1).
  2. In the staging sandbox app, pin @datadog/vite-plugin to it, reinstall, and fully restart Vite.
  3. Confirm /@id/virtual:dd-apps-dev-client is served and the *.backend.ts files load as proxy stubs.
  4. In the Live Preview pane, run a backend function that uses a connection: the posted run-query carries bundle.allowedConnectionIds, and the preview-async run succeeds as the viewer (staging audit events).
  5. Repeat in Firefox, where the parent origin comes from the ping.
  • Not run: the packed-plugin check in a scaffolded @datadog/create-apps app; the staging run covers the same path.
Captured dev server, browser and build output
HTTP 200 text/plain
HTTP 200 application/json
["code","allowedConnectionIds"] ["conn-1"] code===plain: true
{"success":false,"error":"Backend function \"nope.nope\" not found"}
HTTP 404 application/json
<script type="module" src="/@id/virtual:dd-apps-dev-client"></script>
dev client module: HTTP 200 text/javascript
<script type="module" src="/myapp/@id/virtual:dd-apps-dev-client"></script>
HTTP 200
HTTP 415
HTTP 403
HTTP 200
##### chromium
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com","appPath":"/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com","appPath":"/nested/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com.evil.example","appPath":"/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com.evil.example","referrer":"","bundleFetches":[],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"chromium","type":"app-builder:run-query","bundleFetches":[],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com","frameOrigin":"https://preview.qa.test","appPath":"/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com","referrer":"https://app.datadoghq.com/","bundleFetches":["https://preview.qa.test/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com.evil.example","frameOrigin":"https://preview.qa.test","appPath":"/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com.evil.example","referrer":"https://app.datadoghq.com.evil.example/","bundleFetches":[],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"chromium","mode":"never","callMs":[54,74,56,23],"pings":0,"hasBundle":[true,true,true,true],"bundleFetches":4,"warnings":[]}
{"browser":"chromium","mode":"late","callMs":[18,12,11,24],"pings":0,"hasBundle":[true,true,true,true],"bundleFetches":4,"warnings":[]}
##### firefox
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com","appPath":"/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com","appPath":"/nested/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com.evil.example","appPath":"/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"firefox","type":"app-builder:run-query","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com","frameOrigin":"https://preview.qa.test","appPath":"/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["https://preview.qa.test/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com.evil.example","frameOrigin":"https://preview.qa.test","appPath":"/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["https://preview.qa.test/__dd/debugBundle"],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"firefox","mode":"never","callMs":[2011,10,8,24],"pings":1,"hasBundle":[false,true,true,true],"bundleFetches":4,"warnings":["[dd-apps] Sending \"99d2b5f5801ea60aedc25bca60c1f7963eb4a646f9dc29b1016b2e004988dbe0.postMessage\" without its dev server bundle: no answer came back from the parent page, so it can't be confirmed as a Datadog page"]}
{"browser":"firefox","mode":"late","callMs":[2012,40,42,388],"pings":1,"hasBundle":[false,true,true,true],"bundleFetches":4,"warnings":["[dd-apps] Sending \"99d2b5f5801ea60aedc25bca60c1f7963eb4a646f9dc29b1016b2e004988dbe0.postMessage\" without its dev server bundle: no answer came back from the parent page, so it can't be confirmed as a Datadog page"]}
QA_BUILD_DONE
4179 qa-dist/assets/index-BEhnKJUA.js
qa-dist/assets/index-BEhnKJUA.js:0
qa-dist/index.html:0
QA harness source (dev servers, app and parent pages, browser and build scripts)
  • Layout: qa-app/ holds index.html, nested/index.html (a copy), main.js, multi.html, multi.js, parent.html, parent-ping.html, backend/actionCatalogCall.backend.ts and node_modules/@datadog/action-catalog (both copied with cp -RL from packages/tests/src/_jest/fixtures/apps_backend_project). W is this repo's checkout and S the harness directory, both passed as environment variables; QA_BROWSER picks chromium (default) or firefox.
<!-- qa-app/index.html (and qa-app/nested/index.html) -->
<!doctype html>
<html><body><script type="module" src="/main.js"></script></body></html>
// qa-app/main.js
import { postMessage as runBackend } from './backend/actionCatalogCall.backend.ts';
runBackend().then(
    (result) => { window.__result = result; },
    (error) => { window.__error = String(error); },
);
<!-- qa-app/parent.html -->
<!doctype html>
<html><body>
<iframe src="/index.html"></iframe>
<script>
window.addEventListener('message', (event) => {
    if (event.data && event.data.type === 'app-builder:ping') {
        event.source.postMessage({ type: 'app-builder:pong', requestId: event.data.requestId, availableQueries: [] }, '*');
    }
    if (event.data && event.data.type === 'app-builder:run-query') {
        window.__message = event.data;
        event.source.postMessage({ type: 'app-builder:run-query:response', requestId: event.data.requestId, success: true, result: { data: 'reply-from-parent' } }, '*');
    }
});
</script>
</body></html>
<!-- qa-app/multi.html -->
<!doctype html>
<html><body><script type="module" src="/multi.js"></script></body></html>
// qa-app/multi.js — three back-to-back calls, a 3.5s pause, then a fourth call
import { postMessage as runBackend } from './backend/actionCatalogCall.backend.ts';
const timeCall = async () => {
    const start = performance.now();
    await runBackend();
    return Math.round(performance.now() - start);
};
(async () => {
    const timings = [];
    for (let i = 0; i < 3; i++) {
        timings.push(await timeCall());
    }
    await new Promise((resolve) => setTimeout(resolve, 3500));
    timings.push(await timeCall());
    window.__timings = timings;
})().catch((error) => { window.__error = String(error); });
<!-- qa-app/parent-ping.html: ?mode=never ignores pings; ?mode=late answers each ping 3s late -->
<!doctype html>
<html><body>
<iframe src="/multi.html"></iframe>
<script>
const mode = new URLSearchParams(location.search).get('mode');
window.__pings = 0;
window.__bundles = [];
window.addEventListener('message', (event) => {
    if (event.data && event.data.type === 'app-builder:ping') {
        window.__pings += 1;
        if (mode === 'late') {
            const source = event.source;
            const requestId = event.data.requestId;
            setTimeout(() => source.postMessage({ type: 'app-builder:pong', requestId }, '*'), 3000);
        }
    }
    if (event.data && event.data.type === 'app-builder:run-query') {
        window.__bundles.push('bundle' in event.data);
        event.source.postMessage({ type: 'app-builder:run-query:response', requestId: event.data.requestId, success: true, result: { data: 'ok' } }, '*');
    }
});
</script>
</body></html>
// qa-server.cjs — run: W=<repo> S=<harness dir> node qa-server.cjs
const { W, S } = process.env;
const { createServer } = require(`${W}/node_modules/vite`);
const { datadogVitePlugin } = require(`${W}/packages/published/vite-plugin/dist/src/index.js`);
(async () => {
    const server = await createServer({
        configFile: false,
        root: `${S}/qa-app`,
        cacheDir: `${S}/qa-vite-cache`,
        logLevel: 'warn',
        server: { port: 5199, strictPort: true, hmr: false, allowedHosts: ['preview.qa.test'], fs: { allow: [S, W] } },
        optimizeDeps: { noDiscovery: true },
        plugins: [datadogVitePlugin({ apps: { enable: true } })],
    });
    await server.listen();
    console.log('QA_SERVER_READY');
})();
// qa-server-base.cjs — run: W=<repo> S=<harness dir> node qa-server-base.cjs (qa-server.cjs with base /myapp/ on port 5197)
const { W, S } = process.env;
const { createServer } = require(`${W}/node_modules/vite`);
const { datadogVitePlugin } = require(`${W}/packages/published/vite-plugin/dist/src/index.js`);
(async () => {
    const server = await createServer({
        configFile: false,
        base: '/myapp/',
        root: `${S}/qa-app`,
        cacheDir: `${S}/qa-vite-cache-base`,
        logLevel: 'warn',
        server: { port: 5197, strictPort: true, hmr: false, fs: { allow: [S, W] } },
        optimizeDeps: { noDiscovery: true },
        plugins: [datadogVitePlugin({ apps: { enable: true } })],
    });
    await server.listen();
    console.log('QA_SERVER_READY');
})();
// qa-browser-parent.cjs — run: W=<repo> S=<harness dir> QA_BROWSER=chromium|firefox node qa-browser-parent.cjs <parentOrigin> <appPath>
const fs = require('fs');
const { W, S, QA_BROWSER = 'chromium' } = process.env;
const browserType = require(`${W}/node_modules/playwright`)[QA_BROWSER];
const [parentOrigin, appPath] = process.argv.slice(2);
(async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    const parentHtml = fs.readFileSync(`${S}/qa-app/parent.html`, 'utf8')
        .replace('src="/index.html"', 'src="http://localhost:5199' + appPath + '"');
    const fetches = [];
    page.on('request', (request) => { if (request.url().includes('__dd/debugBundle')) fetches.push(request.url()); });
    await page.route(parentOrigin + '/**', (route) => route.fulfill({ contentType: 'text/html', body: parentHtml }));
    await page.goto(parentOrigin + '/parent.html');
    const frame = page.frames().find((f) => f.url().endsWith(appPath));
    await frame.waitForFunction(() => window.__result !== undefined || window.__error !== undefined, null, { timeout: 60000 });
    const message = await page.evaluate(() => window.__message);
    console.log(JSON.stringify({
        browser: QA_BROWSER,
        parentOrigin,
        appPath,
        devClient: await frame.evaluate(() => typeof globalThis.DD_APPS_PREPARE_CALL === 'function'),
        ancestorOrigin: await frame.evaluate(() => (location.ancestorOrigins ? location.ancestorOrigins[0] : 'unsupported')),
        referrer: await frame.evaluate(() => document.referrer),
        bundleFetches: fetches,
        hasBundle: 'bundle' in message,
        allowedConnectionIds: message.bundle && message.bundle.allowedConnectionIds,
        frameResult: await frame.evaluate(() => window.__result ?? window.__error),
    }));
    await browser.close();
})().catch((error) => { console.error(error); process.exit(1); });
// qa-browser.cjs — run: W=<repo> QA_BROWSER=chromium|firefox node qa-browser.cjs (parent page served by the dev server)
const { W, QA_BROWSER = 'chromium' } = process.env;
const browserType = require(`${W}/node_modules/playwright`)[QA_BROWSER];
(async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    const fetches = [];
    page.on('request', (request) => { if (request.url().includes('__dd/debugBundle')) fetches.push(request.url()); });
    await page.goto('http://localhost:5199/parent.html');
    const frame = page.frames().find((f) => f.url().endsWith('/index.html'));
    await frame.waitForFunction(() => window.__result !== undefined || window.__error !== undefined, null, { timeout: 60000 });
    const message = await page.evaluate(() => window.__message);
    console.log(JSON.stringify({
        browser: QA_BROWSER,
        type: message.type,
        bundleFetches: fetches,
        hasBundle: 'bundle' in message,
        frameResult: await frame.evaluate(() => window.__result ?? window.__error),
    }));
    await browser.close();
})().catch((error) => { console.error(error); process.exit(1); });
// qa-browser-https.cjs — run: W=<repo> S=<harness dir> QA_BROWSER=chromium|firefox node qa-browser-https.cjs <parentOrigin> <appPath>
// Like qa-browser-parent.cjs, but the frame is served from an https origin (proxied to the
// local dev server), as a real preview is.
const fs = require('fs');
const { W, S, QA_BROWSER = 'chromium' } = process.env;
const browserType = require(`${W}/node_modules/playwright`)[QA_BROWSER];
const [parentOrigin, appPath] = process.argv.slice(2);
const FRAME_ORIGIN = 'https://preview.qa.test';
(async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    const parentHtml = fs.readFileSync(`${S}/qa-app/parent.html`, 'utf8')
        .replace('src="/index.html"', 'src="' + FRAME_ORIGIN + appPath + '"');
    const fetches = [];
    page.on('request', (request) => { if (request.url().includes('__dd/debugBundle')) fetches.push(request.url()); });
    await page.route(parentOrigin + '/**', (route) => route.fulfill({ contentType: 'text/html', body: parentHtml }));
    await page.route(FRAME_ORIGIN + '/**', async (route) => {
        const target = route.request().url().replace(FRAME_ORIGIN, 'http://localhost:5199');
        // Keep the browser's Host, as the Live Preview proxy does, so it matches the Origin.
        const headers = { ...route.request().headers(), host: 'preview.qa.test' };
        const response = await route.fetch({ url: target, headers });
        await route.fulfill({ response });
    });
    await page.goto(parentOrigin + '/parent.html');
    const frame = page.frames().find((f) => f.url().endsWith(appPath));
    await frame.waitForFunction(() => window.__result !== undefined || window.__error !== undefined, null, { timeout: 60000 });
    const message = await page.evaluate(() => window.__message);
    console.log(JSON.stringify({
        browser: QA_BROWSER,
        parentOrigin,
        frameOrigin: FRAME_ORIGIN,
        appPath,
        devClient: await frame.evaluate(() => typeof globalThis.DD_APPS_PREPARE_CALL === 'function'),
        ancestorOrigin: await frame.evaluate(() => (location.ancestorOrigins ? location.ancestorOrigins[0] : 'unsupported')),
        referrer: await frame.evaluate(() => document.referrer),
        bundleFetches: fetches,
        hasBundle: 'bundle' in message,
        allowedConnectionIds: message.bundle && message.bundle.allowedConnectionIds,
        frameResult: await frame.evaluate(() => window.__result ?? window.__error),
    }));
    await browser.close();
})().catch((error) => { console.error(error); process.exit(1); });
// qa-browser-ping.cjs — run: W=<repo> S=<harness dir> QA_BROWSER=chromium|firefox node qa-browser-ping.cjs never|late
const fs = require('fs');
const { W, S, QA_BROWSER = 'chromium' } = process.env;
const browserType = require(`${W}/node_modules/playwright`)[QA_BROWSER];
const mode = process.argv[2];
const PARENT = 'https://app.datadoghq.com';
(async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    const parentHtml = fs.readFileSync(`${S}/qa-app/parent-ping.html`, 'utf8')
        .replace('src="/multi.html"', 'src="http://localhost:5199/multi.html"');
    const warnings = [];
    page.on('console', (message) => { if (message.type() === 'warning' && message.text().includes('[dd-apps]')) warnings.push(message.text()); });
    const fetches = [];
    page.on('request', (request) => { if (request.url().includes('__dd/debugBundle')) fetches.push(request.url()); });
    await page.route(PARENT + '/**', (route) => route.fulfill({ contentType: 'text/html', body: parentHtml }));
    await page.goto(`${PARENT}/parent-ping.html?mode=${mode}`);
    const frame = page.frames().find((f) => f.url().endsWith('/multi.html'));
    await frame.waitForFunction(() => window.__timings !== undefined || window.__error !== undefined, null, { timeout: 60000 });
    console.log(JSON.stringify({
        browser: QA_BROWSER,
        mode,
        callMs: await frame.evaluate(() => window.__timings ?? window.__error),
        pings: await page.evaluate(() => window.__pings),
        hasBundle: await page.evaluate(() => window.__bundles),
        bundleFetches: fetches.length,
        warnings,
    }));
    await browser.close();
})().catch((error) => { console.error(error); process.exit(1); });
// qa-build.cjs — run: W=<repo> S=<harness dir> node qa-build.cjs
const { W, S } = process.env;
const { build } = require(`${W}/node_modules/vite`);
const { datadogVitePlugin } = require(`${W}/packages/published/vite-plugin/dist/src/index.js`);
build({
    configFile: false,
    root: `${S}/qa-app`,
    logLevel: 'warn',
    build: { outDir: `${S}/qa-dist`, emptyOutDir: true },
    plugins: [datadogVitePlugin({ apps: { enable: true } })],
}).then(() => console.log('QA_BUILD_DONE'), (error) => { console.error(error); process.exit(1); });

Blast Radius

  • No feature flag. Risk: Low.
  • Production builds: unchanged; the extractor's fail-closed change is in [APPS-2947 Fix: fail the build on connection IDs read from objects that calls or aliases write to]([APPS-2947] Fix: fail the build on connection IDs read from objects that calls or aliases write to #619).
  • Deployed apps: the same run-query to '*', but replies not from window.parent are ignored and malformed ones reject instead of hanging; App Builder replies from the parent.
  • Dev server: /__dd/debugBundle returns JSON only for Accept: application/json, Axios's default (no Axios caller found), and framed dev calls wait up to 60s for their function's build.

Out of Scope / Follow-ups

13 items deferred
Item Status Next step
The parent must treat bundle as untrusted app input: any script in the frame can post arbitrary code and allowedConnectionIds Handled in the Live Preview pane in the Datadog app (owner-only); consent deferred there Not needed for the MVP; a consent check is needed before non-owners can run backend functions
The same-origin fetch relies on the pane's allow-same-origin grant, and a referrerpolicy that masks ancestorOrigins[0] as "null" would skip the bundle, since that case doesn't fall back to a ping Holds today Re-check if the pane's sandbox or referrerpolicy changes
In Firefox, the first calls on a dev page wait up to 2s each for a ping answer, until a parent message arrives or two pings go unanswered Accepted None
A failed bundle fetch, or a ping that gets no answer, sends the call without a bundle and only warns in the app's frame Deferred Consider forwarding the cause to the Live Preview pane
The bare Datadog site and any single-label subdomain of it (including www and docs) are accepted as the parent, to keep custom org subdomains working Accepted for GA (decided offline) None; the bundle carries no credentials and is posted only to that checked origin
Each framed dev call builds its function on request, with no cache: 20–34 ms one at a time and 80–84 ms with 5 at once in a Live Preview sandbox (8 CPUs); a deployed app builds at upload instead Deferred (decided offline) Cache each function's bundle until its source changes
The bundle fetch needs Origin to match Host, so a preview edge that rewrites Host gets 403 and the pane refuses the call Depends on the per-preview host design [APPS-2953] Fix: accept the public Origin behind a proxy that rewrites Host if the router rewrites Host
acceptsJson honors q=0 but not relative q-values, and the JSON response sets no Vary: Accept Deferred (decided offline): negotiation is kept over a dedicated route None
Concurrent dev calls can post out of call order, since each one waits for its own bundle Deferred (decided offline) None
No total time cap across the ping (2s), the bundle fetch (60s) and the response (120s); a stuck dev hook adds up to 120s more Deferred (decided offline) None
A postMessage throw rejects with the raw error, not a BackendFunctionError (pre-existing) Deferred (decided offline) None
When no Datadog parent origin is known, the call posts with '*', so its reply is checked against the parent window and request id but not an origin Accepted None
A dev-client hook whose result uses custom iterators, getters or Proxies is out of scope; same-page script can already post to the parent Accepted None

Documentation

🤖 Generated with Claude Code

@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Pipelines  Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 477b4b9 | Docs | View more details | Give us feedback!

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2947-debug-bundle-connection-ids branch from 5126f2b to 5c7bf06 Compare October 14, 2026 21:45
@tyffical
tyffical marked this pull request as ready for review October 16, 2026 01:06
@tyffical
tyffical requested review from a team as code owners October 16, 2026 01:06
@tyffical
tyffical requested review from Chau-DDOG, duncanista, oliverli and sdkennedy2 and removed request for a team October 16, 2026 01:06
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2947-debug-bundle-connection-ids branch from 5c7bf06 to 43bb75f Compare October 16, 2026 02:08

@oliverli oliverli left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation looks correct. I found no correctness or security problems. The inline comments are about test coverage only, and they do not block this PR.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2947-debug-bundle-connection-ids branch 3 times, most recently from 9dbcd7f to d51394c Compare October 26, 2026 20:50
tyffical and others added 2 commits October 26, 2026 17:12
POST /__dd/debugBundle answers with { code, allowedConnectionIds } when
the Accept header lists application/json without q=0, and keeps the
plain-text bundle otherwise. The route path and response type live in
the shared backend protocol so the browser client can use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dev client, built as apps-dev-client, installs a hook the postMessage
transport calls before each run-query. When the page is framed by a
Datadog page, found through ancestorOrigins or, on Firefox, from the
origin of the parent's answer to a ping, the hook fetches the function's
bundle from /__dd/debugBundle and returns it with the parent's exact
origin. A hook that fails, throws or stalls never stops the call. A
parent with an opaque ("null") origin gets the call without a bundle,
and the page warns once.

The Vite plugin prepends a module script for virtual:dd-apps-dev-client
to served HTML, ahead of Vite's base rewrite, and loads the built
apps-dev-client file for it. The virtual id resolves only for client
requests while the dev server runs.

The transport posts a bundle only to an exact origin and accepts a
run-query response only from window.parent and, when it posted to an
exact origin, only from that origin. It copies the bundle's
allowedConnectionIds by index into a plain array before checking them,
so an array subclass can't choose what gets posted through
Symbol.species.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2947-debug-bundle-connection-ids branch from d51394c to 477b4b9 Compare October 26, 2026 21:16
@tyffical

Copy link
Copy Markdown
Contributor Author

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-10-26 22:02:57 UTC ℹ️ Start processing command /merge


2026-10-26 22:03:01 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 2m (p90).


2026-10-26 22:04:24 UTC ℹ️ MergeQueue: This merge request was merged

@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 3cb1023 into master Oct 26, 2026
15 of 17 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the tiffany.trinh/apps-2947-debug-bundle-connection-ids branch October 26, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants